Flutter Widget의 불변성과 rebuild 이해하기

Flutter Widget의 불변성과 rebuild 이해하기

한눈에 보기

Flutter의 Widget은 화면 객체 자체가 아니라 UI의 불변 설정 값이다. rebuild 때 새 Widget 인스턴스가 만들어져도 같은 위치의 Element가 runtimeTypekey를 비교해 기존 연결을 갱신할 수 있다. 따라서 Widget 생성, build 호출, layout, paint를 모두 같은 비용으로 보아서는 안 된다. build()는 빠르고 부수 효과 없이 유지하고, 실제 병목은 DevTools로 측정한다.

Flutter를 처음 사용할 때 가장 낯선 부분 중 하나는 Widget이 불변이라는 설명이다. Counter 숫자가 바뀌고 애니메이션이 움직이는데 UI의 기본 단위가 바뀌지 않는 객체라니 모순처럼 들린다.

class Greeting extends StatelessWidget {
  const Greeting({
    super.key,
    required this.name,
  });

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text('Hello, $name');
  }
}

namefinal이고 Widget 자체를 수정하는 메서드는 없다. 이름이 바뀌면 기존 Greeting을 수정하는 대신 새로운 설정 객체를 만든다.

Greeting(name: 'Mina')
Greeting(name: 'Jisoo')

Flutter가 매번 화면 전체를 버리고 새로 만드는 것은 아니다. 이 동작을 이해하려면 소스 코드에 보이는 Widget tree 뒤에 지속되는 Element tree와 RenderObject tree가 있다는 사실부터 봐야 한다.

목차

Widget은 화면이 아니라 설정이다

Flutter API 문서에서 Widget은 Element의 configuration으로 설명된다. 색상, padding, 자식 관계처럼 “이 위치의 UI가 어떤 모습이어야 하는가”를 선언한다.

const Padding(
  padding: EdgeInsets.all(16),
  child: Text('Profile'),
)

이 객체가 직접 픽셀을 그리는 것은 아니다.

같은 Widget 인스턴스를 여러 위치에서 사용할 수도 있다.

const spacer = SizedBox(height: 12);

Column(
  children: [
    const Text('이름'),
    spacer,
    const TextField(),
    spacer,
    const Text('이메일'),
  ],
)

spacer는 하나의 불변 설정 객체지만 트리에 삽입될 때 각 위치에 별도의 Element가 만들어진다. Widget identity만으로 화면상의 위치를 나타낼 수 없는 이유다.

용어 구분

“Widget이 화면에 있다”는 표현은 편리하지만 정확히는 특정 위치의 Element가 현재 Widget을 configuration으로 가지고 있다.

세 개의 트리를 구분하기

Flutter UI를 이해할 때 다음 세 트리를 나눠 보는 것이 유용하다.

flowchart LR
    W["Widget tree
불변 설정"] --> E["Element tree
위치·수명·상태 연결"] E --> R["RenderObject tree
layout·paint·hit test"] R --> P["화면의 pixels"]

Widget tree

build()가 반환하는 선언적인 UI 구성이다. state가 달라지면 새로운 Widget 객체들이 만들어질 수 있다. 비교적 짧은 수명의 값이다.

Element tree

Widget이 특정 위치에 실제로 사용된 인스턴스를 나타낸다. frame 사이에 지속되며 기존 Widget과 새 Widget을 연결한다. BuildContext도 Element가 구현하는 interface다.

RenderObject tree

크기와 위치를 계산하고 그리며 hit testing에 참여한다. 모든 Widget에 RenderObject가 하나씩 있는 것은 아니다. StatelessWidget, StatefulWidget 같은 composition Widget은 자식 Widget을 만들고, 구체적인 RenderObjectWidget 계열이 RenderObject를 구성한다.

간단한 코드도 내부 트리는 더 깊을 수 있다.

Container(
  color: Colors.blue,
  child: const Text('A'),
)

Container는 전달된 속성에 따라 ColoredBox, padding, constraints 같은 다른 Widget 조합을 만들 수 있다. DevTools에서 소스보다 깊은 트리가 보이는 것은 이상한 일이 아니다.

구분 주된 역할 수명 변경 방식
Widget UI 설정 선언 짧음 새 객체 생성
Element 트리 위치와 연결 관리 상대적으로 지속 새 Widget으로 update
RenderObject layout과 paint 상대적으로 지속 속성 갱신 후 필요한 phase 표시

rebuild 때 실제로 일어나는 일

state 변경으로 Element가 dirty가 되면 framework는 해당 부분의 build()를 다시 호출한다.

setState(() {
  count += 1;
});

setState가 Widget 객체를 직접 수정하는 것은 아니다. 상태 변경을 수행하고 연결된 Element가 다시 build되어야 한다고 알린다.

개념적인 흐름은 다음과 같다.

sequenceDiagram
    participant U as 사용자 입력
    participant S as State
    participant E as Element
    participant W as 새 Widget 설정
    participant R as RenderObject

    U->>S: 버튼 tap
    S->>S: setState로 count 변경
    S->>E: dirty 표시
    E->>W: build 호출
    W-->>E: 새 자식 Widget 구성
    E->>E: 이전·새 configuration 비교
    E->>R: 변경된 속성만 갱신
    R->>R: 필요할 때 layout / paint

부모가 새 자식 Widget을 반환하면 Element는 기존 자식과 새 자식을 맞춰 본다. 같은 위치에서 runtimeTypekey가 같다면 기존 Element를 새 configuration으로 update할 수 있다. 다르면 기존 Element를 제거하고 새로운 Element를 inflate한다.

따라서 “Widget이 새로 생성됐다”와 “Element 및 상태가 폐기됐다”는 같은 말이 아니다.

Text('Count: 1')
Text('Count: 2')

Text Widget은 다른 객체지만 같은 위치에서 type과 key가 같다면 기존 Element와 RenderParagraph 계열 객체를 갱신할 수 있다.

runtimeType과 key가 재사용을 결정한다

Flutter의 Widget.canUpdate는 개념적으로 두 Widget의 runtimeTypekey가 같은지 확인한다.

old.runtimeType == new.runtimeType
old.key == new.key

key가 없다면 같은 부모 아래의 위치와 type이 중요한 기준이 된다.

Column(
  children: [
    UserCard(user: users[0]),
    UserCard(user: users[1]),
  ],
)

목록 순서가 바뀌었는데 각 UserCard에 내부 state가 있다면 위치 기준 연결 때문에 state가 다른 사용자에게 붙은 것처럼 보일 수 있다.

Column(
  children: [
    for (final user in users)
      UserCard(
        key: ValueKey(user.id),
        user: user,
      ),
  ],
)

ValueKey(user.id)는 이 configuration이 어떤 데이터 항목을 나타내는지 framework에 알려 준다.

key는 많을수록 좋은 것이 아니다.

UserCard(
  key: UniqueKey(),
  user: user,
)

위 코드는 rebuild마다 의도적으로 identity를 바꾸므로 재사용을 막는다. state 초기화가 명확한 요구일 때만 사용한다.

State 수명은 다음 글에서

StatefulWidget과 State가 분리된 이유, didUpdateWidget, dispose의 수명은 StatefulWidget의 상태가 State 객체에 있는 이유에서 이어서 다룬다.

불변성이 주는 이점

Widget이 mutable하다면 framework는 기존 객체 내부의 어떤 필드가 바뀌었는지 추적해야 한다. 자식 관계까지 객체 내부에서 직접 바뀌면 이전 상태와 새 상태를 분리해 비교하기 어려워진다.

불변 설정은 변화를 다음처럼 표현한다.

final before = StatusBadge(
  status: OrderStatus.pending,
);

final after = StatusBadge(
  status: OrderStatus.completed,
);

기존 설정과 새 설정이 별도 값이므로 Element는 자신의 위치를 유지하면서 최신 configuration으로 갱신할 수 있다.

불변성이 주는 실질적인 장점은 다음과 같다.

이 모델에서는 개발자가 “기존 label을 찾아 문자열을 수정”하지 않는다.

setState(() {
  status = OrderStatus.completed;
});

현재 state로 원하는 UI를 다시 설명하면 framework가 지속되는 객체를 효율적으로 갱신한다.

build는 여러 번 호출될 수 있다

build()는 내가 예상한 횟수만 호출되는 lifecycle callback이 아니다. framework가 필요하다고 판단하면 호출할 수 있고, animation 중에는 frame마다 호출될 수도 있다.

다음 상황에서 rebuild가 발생할 수 있다.

따라서 build는 빠르고 반복 가능해야 한다.

@override
Widget build(BuildContext context) {
  final theme = Theme.of(context);

  return Text(
    title,
    style: theme.textTheme.titleMedium,
  );
}

위와 같은 가벼운 Widget 조합과 값 선택은 build의 역할이다.

반면 큰 JSON parsing, 이미지 처리, 동기 파일 I/O를 build에서 수행하면 반복 호출 때 frame budget을 소비한다.

@override
Widget build(BuildContext context) {
  final report = parseLargeReport(rawJson);
  return ReportView(report: report);
}

변하지 않는 계산은 사전에 수행해 state나 view model에 저장하고, 입력에 따른 파생 계산은 필요 범위와 비용을 측정한 뒤 memoization을 고려한다. 작은 계산까지 무조건 cache하면 invalidation 복잡성만 늘 수 있다.

build 안에 부수 효과를 두면 안 되는 이유

build가 여러 번 호출될 수 있으므로 네트워크 요청, analytics 전송, controller 변경 같은 부수 효과를 넣으면 중복 실행된다.

@override
Widget build(BuildContext context) {
  repository.loadProfile();
  analytics.logScreenView('profile');

  return const ProfileSkeleton();
}

부모 rebuild나 theme 변경만으로도 요청과 로그가 반복될 수 있다.

Future를 FutureBuilder 안에서 즉석 생성하는 코드도 같은 문제를 만든다.

FutureBuilder<Profile>(
  future: repository.loadProfile(),
  builder: (context, snapshot) {
    return ProfileContent(snapshot: snapshot);
  },
)

State 수명 동안 한 번 시작해야 한다면 initState에서 Future를 보관한다.

class _ProfilePageState extends State<ProfilePage> {
  late final Future<Profile> _profileFuture;

  @override
  void initState() {
    super.initState();
    _profileFuture = widget.repository.loadProfile();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Profile>(
      future: _profileFuture,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return const ProfileErrorView();
        }

        if (!snapshot.hasData) {
          return const ProfileSkeleton();
        }

        return ProfileContent(
          profile: snapshot.requireData,
        );
      },
    );
  }
}

widget.repository가 부모 update로 바뀔 수 있다면 didUpdateWidget에서 기존 입력과 비교해 Future를 새로 만드는 정책이 필요하다. 무조건 late final이 정답은 아니고 요청 수명이 어떤 configuration에 묶이는지를 정해야 한다.

build의 순수성은 재시도 가능성을 만든다

같은 입력으로 다시 build해도 외부 시스템 상태를 추가로 바꾸지 않아야 한다. UI 설명과 부수 효과의 수명을 분리하면 예상치 못한 rebuild가 버그가 되지 않는다.

const Widget이 줄이는 작업

컴파일 시점에 결정되는 Widget에는 const 생성자를 사용할 수 있다.

class EmptyState extends StatelessWidget {
  const EmptyState({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('표시할 항목이 없습니다.'),
    );
  }
}

같은 const 표현은 canonicalized instance로 재사용될 수 있고 framework가 동일한 Widget 인스턴스를 만났을 때 하위 update 작업을 건너뛸 기회를 준다.

Column(
  children: [
    Text(dynamicTitle),
    const SizedBox(height: 16),
    const HelpMessage(),
  ],
)

부모가 rebuild되어도 변하지 않는 SizedBoxHelpMessage는 const로 분리할 수 있다.

하지만 const를 붙이는 것이 애플리케이션 성능의 전부는 아니다.

prefer_const_constructors 같은 lint는 좋은 기본값이지만 실제 frame 병목을 대신 측정해 주지는 않는다.

rebuild와 layout·paint는 같은 말이 아니다

Flutter frame pipeline의 주요 phase를 구분해야 한다.

flowchart LR
    B["Build
Widget 구성"] --> L["Layout
크기·위치 계산"] L --> P["Paint
그리기 명령 생성"] P --> C["Compositing / Raster"]

build가 실행됐다고 모든 하위 RenderObject가 반드시 layout과 paint를 다시 하는 것은 아니다. 새 configuration의 속성이 기존과 같다면 RenderObject 변경이 없을 수 있다.

반대로 build가 작은 범위여도 바뀐 속성이 넓은 layout이나 paint를 유발할 수 있다.

변경 예 예상되는 주된 영향
Text 문자열 변경 build, text layout, paint
색상만 변경 build/update, 주로 paint
부모 constraints 변경 하위 layout 전파 가능
opacity animation 구성에 따라 compositing 또는 paint
같은 const child 재사용 하위 build update를 건너뛸 가능성

“rebuild가 100번이라 느리다”는 결론을 내리기 전에 어느 phase가 frame budget을 넘는지 확인한다.

용어를 정확히 쓰면 최적화가 쉬워진다

Widget rebuild, RenderObject layout, repaint, raster thread 작업을 구분해 기록한다. 같은 화면 끊김이라도 해결책이 다르다.

rebuild 범위를 작게 만드는 방법

State를 가장 가까운 곳에 두면 dirty 범위를 줄일 수 있다.

class ProductPage extends StatelessWidget {
  const ProductPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Column(
      children: [
        ProductHeader(),
        QuantitySelector(),
        ProductDescription(),
      ],
    );
  }
}

수량 state가 QuantitySelector 내부에만 필요하다면 페이지 root에 둘 이유가 없다.

class QuantitySelector extends StatefulWidget {
  const QuantitySelector({super.key});

  @override
  State<QuantitySelector> createState() {
    return _QuantitySelectorState();
  }
}

class _QuantitySelectorState extends State<QuantitySelector> {
  int quantity = 1;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        IconButton(
          onPressed: quantity > 1
              ? () => setState(() => quantity -= 1)
              : null,
          icon: const Icon(Icons.remove),
        ),
        Text('$quantity'),
        IconButton(
          onPressed: () => setState(() => quantity += 1),
          icon: const Icon(Icons.add),
        ),
      ],
    );
  }
}

외부 상태 관리 도구를 사용할 때도 전체 store를 watch하기보다 필요한 slice만 구독한다. 정확한 API는 사용하는 패키지에 따라 다르지만 원칙은 같다.

다음 최적화를 순서대로 고려한다.

  1. build 안의 I/O와 비싼 계산을 제거한다.
  2. state와 listen 범위를 실제 소비자 가까이 둔다.
  3. 변하지 않는 Widget을 const로 만든다.
  4. animation에서 변하지 않는 child를 builder 밖으로 분리한다.
  5. 긴 목록은 lazy builder를 사용한다.
  6. 측정으로 확인된 layout·paint 병목을 별도로 최적화한다.

Widget을 작은 클래스로 분리하면 framework가 build 경계를 건너뛸 기회가 생기고 코드 책임도 선명해질 수 있다. 단순 helper method는 호출될 때마다 부모 build의 일부로 실행된다.

DevTools로 실제 비용 확인하기

추측 대신 Flutter DevTools와 profile mode를 사용한다. debug mode의 assert, service extension, JIT 특성은 release 성능과 다르므로 성능 판단에 그대로 사용하지 않는다.

확인할 항목은 다음과 같다.

가설을 세운 뒤 같은 시나리오를 전후 비교한다.

시나리오: 상품 수량 버튼을 20회 빠르게 누른다

변경 전
- page root state 갱신
- 평균 UI frame: 가상 수치 13ms
- header와 description rebuild

변경 후
- selector 내부 state 갱신
- 평균 UI frame: 가상 수치 6ms
- selector만 rebuild

위 숫자는 측정 기록 형식을 보여 주는 예시일 뿐 실제 프로젝트 결과가 아니다.

성능 목표는 rebuild 0회가 아니다. 한 frame 안에 필요한 UI 갱신을 끝내고 사용자 입력에 안정적으로 반응하는 것이다.

실무 체크리스트

Widget 설계

Build

성능 검증

마무리

Flutter에서 Widget이 불변이라는 말은 화면이 변하지 않는다는 뜻이 아니다. 변화할 때 기존 객체를 수정하는 대신 현재 state를 반영한 새로운 UI configuration을 만든다는 뜻이다.

Widget은 비교적 가볍고 짧은 수명의 선언이다. Element는 특정 트리 위치에서 Widget을 받아 지속되는 상태와 RenderObject 연결을 관리한다. RenderObject는 layout과 paint를 담당한다. 새 Widget이 만들어져도 같은 위치에서 type과 key가 맞으면 기존 Element를 update할 수 있다.

이 구조 덕분에 개발자는 화면 객체를 직접 찾아 수정하지 않고 UI = f(state) 형태로 작성할 수 있다. 대신 build()가 여러 번 호출될 수 있다는 계약을 지켜야 한다. build는 빠르고 부수 효과 없이 유지하고, const와 작은 state 범위는 측정 결과에 따라 활용한다.

Widget 생성은 곧 화면 전체 재생성이 아니다. 불변 Widget은 새 설정을 전달하고, 지속되는 Element와 RenderObject가 필요한 부분만 갱신한다.

관련 노트

참고 자료